把服務的規則寫成一張表,AI 寫的程式碼要一格一格對答案。
exists validation 規則怎麼變成 enumeration 工具某個會員制社群平台有一條規則:只有完成身分驗證的會員,才能發布公開貼文,而且每人最多 N 篇。
原本的檢查邏輯是:「這個會員的貼文數量有沒有超過上限?沒有的話就讓他發布。」
數量檢查本身寫得沒錯,問題是它完全沒有檢查會員驗證過沒有。更糟的是,公開的貼文列表也沒有過濾,未驗證會員的內容照樣對所有人顯示。
這是我實際修過的案例(已去識別化)。這種程式碼也是 AI 很容易寫出來的:你在提示詞裡講了「每人最多 N 篇」,它就寫數量檢查;「驗證過才能發」如果只寫在需求文件的另一頁,它就不會知道。
大家說「有沒有權限」,其實混在一起講了四件事:
| 檢查 | 問的問題 | 例子 |
|---|---|---|
| 登入 | 你是誰? | 沒登入不能發文 |
| 身分狀態 | 你現在是什麼狀態? | 驗證過才能發文、付費會員才能匯出 |
| 角色 | 你是什麼角色? | 管理員才能看後台 |
| 資料擁有者 | 這筆資料是你的嗎? | 只能改自己的清單(第 19 天會教你怎麼測) |
AI 最常做好的是第一種,最常漏掉的是第二種和第四種。
你心裡知道服務的規則,但 AI 不知道,除非你寫下來。換個例子,一個清單服務的規則可能長這樣:
| 動作 | 未登入 | 免費會員 | 未驗證會員 | 付費會員 | 管理員 |
|---|---|---|---|---|---|
| 瀏覽公開清單 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 建立清單 | ❌ | ✅(最多 3 份) | ❌ | ✅(無限) | ✅ |
| 使用 AI 整理 | ❌ | ✅(每天 3 次) | ❌ | ✅ | ✅ |
| 匯出 PDF | ❌ | ❌ | ❌ | ✅ | ✅ |
| 看所有會員 | ❌ | ❌ | ❌ | ❌ | ✅ |
| 修改自己的「已驗證」「方案」欄位 | ❌ | ❌ | ❌ | ❌ | ❌(只有驗證流程、金流 webhook 能改) |
最後一列很容易被忘記:規則靠「已驗證」「付費方案」這些欄位判斷,那誰能改這些欄位本身就是一條規則。第 13 天會看到,「更新個人資料時順便把自己改成已驗證」是 AI 很常留下的洞。
這張表就是你的答案卷。之後每次請 AI 新增功能,都把這張表一起給它。
複製下面這段,貼進你的筆記或 AI 對話裡,把欄和列換成自己服務的身分與動作:
# 權限表:【服務名稱】(最後更新:YYYY-MM-DD)
身分定義:
- 未登入:沒有帶任何登入資訊
- 【身分 A】:【怎麼判斷,例如 profiles.identity_verified_at 不是空的】
- 【身分 B】:【怎麼判斷】
| 動作 | 未登入 | 【身分 A】 | 【身分 B】 | 管理員 |
|---|---|---|---|---|
| 【讀取類動作】 | | | | |
| 【新增類動作】 | | | | |
| 【有次數或數量上限的動作】 | | | | |
| 【只有特定方案能用的動作】 | | | | |
| 修改【判斷身分用的欄位】 | | | | |
填法:
- 每一格填 ✅、❌,或 ✅ 加上限制(例如「最多 3 份」「每天 3 次」「只能是自己的」)
- 「身分」要寫出程式怎麼判斷,不能只寫名稱
- 不確定的格子先填 ❌,之後有需要再開
先讓 AI 只看程式碼,「反推」出目前實際的規則:
請只根據程式碼(不要參考任何文件或註解),整理出這個服務「實際上」的權限規則表。
欄位:動作、未登入、免費會員、未驗證會員、付費會員、管理員。
每一格填「允許」「拒絕」或「沒有檢查」,並附上檢查所在的檔案與行數。
如果某個動作在前端有檢查、伺服器端沒有,請標示為「沒有檢查」。
同一個動作如果有多個入口(例如一般新增和附檔上傳是兩支 API),請分開列出。
先不要修改程式碼。
拿到後,和你自己畫的表逐格比對。不一樣的格子,就是漏洞。
然後再請 AI 修正:
這是正確的權限規則表:【貼上你的表】
請修正所有和實際程式碼不一致的地方,檢查一律放在伺服器端。
每一條規則只在一個地方定義,其他地方呼叫它,不要複製判斷式。
每一格「拒絕」都要有一個自動化測試,證明該身分執行該動作會被拒絕。
每種身分各準備一個帳號,照著權限表一格一格操作。記得用第 3 天〈瀏覽器看得到的,全都是公開的〉的「複製為 fetch 格式」直接送請求,不要只看按鈕有沒有出現。
「讀取」也要測:用未登入的無痕視窗打開公開列表,確認不該出現的內容真的不在裡面。
授權檢查可以分三層來看。BOLA(Broken Object Level Authorization)問的是「這筆資料是不是你的」,第 19 天會教你用兩個帳號測它。這篇的重點是 BFLA(Broken Function Level Authorization):你這個身分能不能執行這個功能。再往上一層是「驗證過才能發布」「免費會員最多三份」這種商業規則。它們在程式碼裡長得不像資安檢查,code review 很容易放過,但它們失效時,一樣是授權漏洞。
OWASP API Security Top 10 對 BFLA 的建議很直接:要有一個一致、容易分析的授權模組,所有功能都呼叫它;而且預設拒絕,每個功能都要明確開放給特定身分。權限表就是這個模組的規格。
那次修正,我把檢查補在三層:
whereHas() 過濾掉未驗證會員的內容第三層最關鍵。前兩層擋的是「寫入」,第三層擋的是「讀取」:就算未來某個新功能忘了檢查,未驗證的內容也不會出現在公開列表上。
但多層檢查有代價。回頭數,同一條「驗證過才能發」的規則,最後出現在六個地方:兩個建立入口的 controller(一般發文、附檔上傳各一支 API)、service 的兩個方法、公開列表的兩個查詢(一般版和分頁版)。下次規則改了,很可能只改到其中幾個。這也是前面提示詞要 AI「多個入口分開列出」的原因:漏掉的常常不是主要那支 API,而是旁邊那支。
比較好的做法是:
// app/Policies/PostPolicy.php — the single definition of the rule
public function publish(Member $member): bool
{
return $member->identity_verified_at !== null
&& $member->posts()->count() < config('posts.max_per_member');
}
// app/Models/Post.php — the read side uses a named scope
public function scopePubliclyVisible(Builder $query): void
{
$query->where('is_public', true)
->whereHas('member', fn (Builder $q) => $q->whereNotNull('identity_verified_at'));
}
每個建立入口都寫 Gate::authorize('publish', Post::class),每個公開列表都從 Post::publiclyVisible() 開始查。之後規則改了,只改這兩個地方。
| 回應 | 攻擊者得到的資訊 |
|---|---|
| 403 Forbidden | 「這個編號的資料存在,只是你不能看」 |
| 404 Not Found | 「查無此資料」,無法分辨存在與否 |
GitHub 就是這樣做的:你沒有權限的私有 repo,API 回 404 而不是 403,官方文件寫明原因是「避免證實私有 repo 存在」。
在另一個專案裡,我刻意讓「草稿狀態的資料」「已封存的資料」「不存在的編號」「別的單位的資料」四種情況,回傳逐字相同的錯誤訊息。原因是只要訊息有一個字不同,攻擊者就能逐一猜編號,拼出你的資料分布。這種回應差異能讓攻擊者確認事實,資安上叫做 oracle。
不是每個地方都要回 404。「你已經登入、但方案不夠」這種情況,回 403 並告訴使用者要升級,沒有洩漏任何別人的資料。判斷方式是:錯誤訊息會不會透露「別人的」資料存不存在、長什麼樣子。
exists validation 規則怎麼變成 enumeration 工具Laravel 的 exists validation 規則很方便,但它會讓「編號不存在」和「編號存在但不屬於你(之後由 Policy 拒絕)」產生兩種不同的錯誤:前者是 422,後者是 403。那個專案的做法是把 exists 改成帶條件的 Rule::exists(),把「屬於哪個單位、是否已發布」一起寫進同一條規則,不管哪種情況都回傳同一個錯誤:
// Before: two different failures reveal whether the id exists
'template_id' => ['required', 'exists:contract_templates,id'],
// After: one rule, one message, no existence oracle
'template_id' => [
'required',
Rule::exists('contract_templates', 'id')
->where('organization_id', $organization->id)
->where('status', TemplateStatus::Published->value),
],
我在 Laravel 13 實測,用同一個帳號送出五種編號:
| 送出的編號 | 改之前(exists:) |
改之後(Rule::exists()) |
|---|---|---|
| 自己單位、已發布 | 200 | 200 |
| 自己單位、草稿 | 200(交給後面的檢查) | 422 The selected template id is invalid. |
| 自己單位、已封存 | 200(交給後面的檢查) | 422 同上 |
| 別的單位 | 200(交給後面的檢查) | 422 同上 |
| 不存在 | 422 | 422 同上 |
改之前,只有「不存在」會在 validation 階段失敗,其他三種要靠後面的 Policy 擋,擋下來的回應又不一樣,差異就出現了。改之後,四種不能用的情況在 validation 階段就被擋下,回應逐字相同。
第 3 天提過,額度要「檢查並扣除」一次完成。「最多 N 篇」有同樣的問題:程式通常先查數量,再新增。如果同時送出 10 個請求,它們可能都在「查數量」這一步看到還沒到上限,然後全部新增成功。這是典型的 TOCTOU(time-of-check to time-of-use)。
用一個已驗證的帳號登入,照第 3 天的方法複製「發文」的請求,然後在主控台貼上:
// Fire 10 identical requests at the same time
const results = await Promise.all(
Array.from({ length: 10 }, () =>
fetch(/* paste the arguments of the copied fetch() here */).then((res) => res.status)
)
);
console.log(results, "成功:", results.filter((s) => s >= 200 && s < 300).length);
成功的數量加上原本已經有的篇數,超過上限就是有問題。一次沒出現不代表沒問題,race condition 看運氣,至少跑 10 輪,每輪之前把測試帳號的資料清掉。
我用 Laravel 13.32、PostgreSQL 17 建了一個上限 3 篇的發文 API,本機伺服器開 12 個 worker,每輪同時送 10 個請求:
| 版本 | 超過上限的輪數 | 單輪最多寫進幾篇 |
|---|---|---|
| 先查數量再新增 | 18/20 | 7 |
交易內 lockForUpdate() 再計數 |
0/20 | 3 |
一個容易踩的坑:php artisan serve 預設一次只處理一個請求,在它上面怎麼測都不會出現 race condition,讓你以為沒問題。要加 --no-reload 並設定 PHP_CLI_SERVER_WORKERS 才會真的並行;最準的是直接測部署後的環境。
只要擋住同一個會員的並行請求,用第一種;已經有計數欄位、或不想拉長交易時間,用第二種:
lockForUpdate())再計數DB::transaction(function () use ($member, $data) {
// Lock the member row so concurrent requests from the same member wait here
$member = Member::whereKey($member->id)->lockForUpdate()->first();
Gate::forUser($member)->authorize('publish', Post::class);
Post::create([...$data, 'member_id' => $member->id]);
});
// Alternative: a counter column updated atomically
$reserved = Member::whereKey($member->id)
->where('posts_count', '<', config('posts.max_per_member'))
->increment('posts_count');
if ($reserved === 0) {
abort(403);
}
注意:SQLite 會直接忽略 lockForUpdate()(Laravel 對 SQLite 不產生鎖定語法)。如果你的測試跑在 SQLite,這段鎖在測試裡沒有作用,並行測試要在 MySQL 或 PostgreSQL 上跑。
如果你的服務是 Next.js+Supabase、前端直接讀寫資料庫,規則就寫在 RLS policy 裡(第 5 天會完整談 RLS)。下面把「驗證過才能公開清單」寫成 policy,而且判斷式只定義一次:
-- The single definition of "verified", in a schema not exposed by the Data API
create function private.is_identity_verified(member_id uuid)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1 from public.profiles
where id = member_id and identity_verified_at is not null
);
$$;
-- Read: public lists are visible only if the owner is verified
create policy "read public lists of verified members"
on public.lists for select to anon, authenticated
using (
user_id = (select auth.uid())
or (is_public and private.is_identity_verified(user_id))
);
-- Write: only verified members may create or switch a list to public
create policy "only verified members create public lists"
on public.lists for insert to authenticated
with check (
user_id = (select auth.uid())
and (not is_public or private.is_identity_verified((select auth.uid())))
);
create policy "only verified members make lists public"
on public.lists for update to authenticated
using (user_id = (select auth.uid()))
with check (
user_id = (select auth.uid())
and (not is_public or private.is_identity_verified((select auth.uid())))
);
-- Members must not be able to mark themselves as verified
revoke update on table public.profiles from authenticated;
grant update (nickname) on table public.profiles to authenticated;
三個細節:
authenticated,只寫 revoke update (identity_verified_at) 沒有用,因為表層級的權限還在。要先整張收回,再逐欄開放。我在本機 PostgreSQL 實測:收回之後,會員改 identity_verified_at 會得到 permission denied,改暱稱照常成功。public 等於讓任何人透過 API 呼叫它。上面放在 private,並設定 search_path = ''。這兩點都是 Supabase 官方文件的要求。user_metadata。使用者可以自己用 supabase.auth.updateUser() 改它。要放在 JWT 裡的話用 app_metadata,但 JWT 要等使用者重新取得 token 才會更新,撤銷驗證不會立刻生效;即時性重要的規則,照上面查資料表。數量上限也可以寫在 policy 的 with check 裡數一數,但它有一樣的 race condition。我用 10 個連線同時新增、上限 3 份:只靠 policy 計數,10 輪都超過上限(單輪最多寫進 10 份);改用 trigger 先鎖會員資料列再計數,10 輪都停在 3 份:
create function private.enforce_list_limit()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
-- Serialize concurrent inserts from the same member
perform 1 from public.profiles where id = new.user_id for update;
if (select count(*) from public.lists where user_id = new.user_id) >= 3 then
raise exception 'list limit reached';
end if;
return new;
end;
$$;
create trigger enforce_list_limit
before insert on public.lists
for each row execute function private.enforce_list_limit();
用 dataset 把權限表直接變成測試(Pest):
dataset('publishers', [
'unverified member' => [fn () => Member::factory()->identityUnverified()->create(), false],
'verified member' => [fn () => Member::factory()->identityVerified()->create(), true],
]);
it('enforces who can publish a post', function (Member $member, bool $allowed) {
$response = $this->actingAs($member)->postJson('/api/posts', ['title' => 'hello']);
$allowed ? $response->assertCreated() : $response->assertForbidden();
})->with('publishers');
it('hides posts of unverified members from the public list', function () {
$hidden = Post::factory()->for(Member::factory()->identityUnverified())->public()->create();
$shown = Post::factory()->for(Member::factory()->identityVerified())->public()->create();
$this->getJson('/api/posts')
->assertOk()
->assertJsonMissing(['id' => $hidden->id])
->assertJsonFragment(['id' => $shown->id]);
});
幾個細節:
beforeEach() 之後才執行,所以可以放 factory。Pest 官方文件要求測試函式的參數寫上型別(Member $member),它才會把 closure 換成執行結果。unverified()。Laravel 預設的 UserFactory 已經有一個 unverified(),意思是「email 還沒驗證」,跟「身分驗證」是兩回事,混用就會測錯東西。assertJsonMissing,API 壞掉回空陣列時測試也會通過。以上測試在 Laravel 13.32+Pest 4.7 實際跑過。權限表每多一格,dataset 跟著多一行,表和測試不會脫節。
登入只回答「你是誰」。把服務的規則寫成一張表,AI 寫的程式碼要一格一格對答案;每條規則只定義一次,寫入和讀取都要擋,數量上限要在並行請求下也成立。